This how-to tutorial demonstrates how to download and process public domain texts from Project Gutenberg programmatically in R using the gutenbergr package, covering text search, batch downloading, and the cleaning of Gutenberg-specific formatting for use in corpus analysis. It is aimed at researchers in digital humanities and corpus linguistics who want to build literary corpora from freely available historical texts.
Author
Martin Schweinberger
Published
2026
Great Court, The University of Queensland
Introduction
This how-to guide shows how to download, inspect, and clean texts from the Project Gutenberg archive using R. Project Gutenberg is one of the oldest and largest freely available digital libraries, containing over 70,000 ebooks whose US copyright has expired. It is an invaluable resource for researchers in literary studies, corpus linguistics, computational humanities, and any field requiring access to large amounts of digitised historical and literary text.
The R package gutenbergr provides convenient programmatic access to the Project Gutenberg catalogue, allowing you to search, filter, and download texts directly into your R session without manual downloading or file management.
Before You Start
This guide assumes basic familiarity with R. If you are new to R, please work through the following tutorials first:
A robust download function — handling mirror failures automatically
Exploring the catalogue — browsing and searching available texts
Filtering by author, language, subject, and rights
Downloading individual texts
Downloading multiple texts simultaneously
Cleaning and preparing downloaded texts — removing boilerplate, splitting into sections, and saving for analysis
Troubleshooting — encoding issues and texts not found
Citation
Martin Schweinberger. 2026. Downloading Texts from Project Gutenberg using R. The Language Technology and Data Analysis Laboratory (LADAL), The University of Queensland, Australia. url: https://ladal.edu.au/tutorials/gutenberg/gutenberg.html (Version 3.1.1). doi: 10.5281/zenodo.19424866.
Setup
Section Overview
What you’ll learn: How to install and load the packages needed for this guide
Installing Packages
Code
# Install required packages — run once, then comment outinstall.packages("gutenbergr") # access to Project Gutenberg catalogue and downloadsinstall.packages("dplyr") # data manipulation (filter, select, mutate)install.packages("stringr") # string processing (cleaning text)install.packages("tidyr") # reshaping datainstall.packages("ggplot2") # visualisationinstall.packages("flextable") # formatted tablesinstall.packages("DT") # interactive data tablesinstall.packages("here") # portable file paths
Loading Packages
Code
# Load packages — run at the start of every sessionlibrary(gutenbergr) # Project Gutenberg interfacelibrary(dplyr) # data manipulationlibrary(stringr) # string processinglibrary(tidyr) # data reshapinglibrary(ggplot2) # plottinglibrary(flextable) # formatted tableslibrary(DT) # interactive HTML tableslibrary(here) # portable file paths
Why Not library(tidyverse)?
Loading individual packages (dplyr, stringr, etc.) is preferable to library(tidyverse) for reproducibility: it makes dependencies explicit, avoids namespace conflicts, and ensures your code works even if the Tidyverse bundle changes. LADAL tutorials follow this best practice throughout.
A Robust Download Function
Section Overview
What you’ll learn: Why direct gutenberg_download() calls sometimes return empty results, and how to define a single reliable helper function that all subsequent downloads use
Project Gutenberg’s servers and mirrors can be unreliable — a direct gutenberg_download() call may silently return zero lines even when the ID is correct. The most robust approach is to:
Try several mirrors in sequence via gutenbergr
Fall back to reading the raw plain-text file directly from the Project Gutenberg cache URL, which is always at https://www.gutenberg.org/cache/epub/{ID}/pg{ID}.txt
We define this logic once as a helper function and use it throughout the guide:
Code
# Helper function: download a single text by Gutenberg ID# Tries gutenbergr mirrors first; falls back to direct URL read if all fail# Arguments:# id : integer gutenberg_id# meta_fields : character vector of metadata columns to attach (passed to gutenberg_download)# title_fallback : title string to use in the fallback data framegutenberg_safe <-function(id, meta_fields ="title", title_fallback =NA_character_) {# List of mirrors to try in order mirrors <-c("http://mirrors.xmission.com/gutenberg/","http://gutenberg.pglaf.org/","https://gutenberg.readingroo.ms/","http://gutenberg.nabasny.com/" ) result <-NULL# Step 1: try each mirror via gutenbergrfor (m in mirrors) {tryCatch({ dl <-gutenberg_download(id, meta_fields = meta_fields, mirror = m)if (!is.null(dl) &&nrow(dl) >0) {message("Downloaded ID ", id, " via mirror: ", m) result <- dlbreak } }, error =function(e) NULL,warning =function(w) NULL) }# Step 2: fall back to direct cache URL if all mirrors failedif (is.null(result) ||nrow(result) ==0) {message("All mirrors failed for ID ", id, " — trying direct cache URL") cache_url <-paste0("https://www.gutenberg.org/cache/epub/", id, "/pg", id, ".txt")tryCatch({ lines <-readLines(url(cache_url), warn =FALSE, encoding ="UTF-8")# Look up title from metadata if not suppliedif (is.na(title_fallback)) { title_fallback <- gutenberg_metadata |> dplyr::filter(gutenberg_id == id) |> dplyr::pull(title) |> dplyr::first() } result <-data.frame(gutenberg_id = id,text = lines,title = title_fallback,stringsAsFactors =FALSE )message("Downloaded ID ", id, " via direct cache URL (", nrow(result), " lines)") }, error =function(e) {stop("Could not download ID ", id, ": ", conditionMessage(e)) }) } result}
Why a Helper Function?
Defining gutenberg_safe() once and calling it throughout means:
Every download in this guide uses the same robust fallback logic
If Project Gutenberg updates its mirror list, you only need to update one place
The function is self-documenting — the mirrors and fallback URL are visible in one location
You can copy gutenberg_safe() directly into your own projects
Exploring the Project Gutenberg Catalogue
Section Overview
What you’ll learn: How to browse and search the full Project Gutenberg catalogue, and what metadata fields are available for filtering
The Metadata Table
The gutenbergr package ships with a metadata table — gutenberg_metadata — that contains information about every text in the Project Gutenberg archive. You can inspect it directly without downloading anything:
Code
# Load the full metadata table# This is a local data frame included with the gutenbergr packageoverview <- gutenberg_metadata# How many texts are available?cat("Total texts in catalogue:", nrow(overview), "\n")
Copyright status (typically 'Public domain in the USA.')
has_text
Whether a plain text version is available for download
Browsing with gutenberg_works()
The gutenberg_works() function is a convenience wrapper around gutenberg_metadata that returns only texts with a downloadable plain text version (has_text == TRUE) in the public domain:
Code
# Browse all available public domain texts with plain text versionsall_works <-gutenberg_works()cat("Texts available via gutenberg_works():", nrow(all_works), "\n")
Filtering the Catalogue
Section Overview
What you’ll learn: How to filter the Project Gutenberg catalogue by author, language, subject/bookshelf, and multiple criteria to find exactly the texts you need
Filter by Author
Author names in the catalogue are stored in “Surname, Firstname” format:
Code
# Find all works by Charles Darwin using exact name formatdarwin_works <-gutenberg_works(author =="Darwin, Charles")cat("Works by Charles Darwin:", nrow(darwin_works), "\n")
Works by Charles Darwin: 37
When unsure of the exact name format, use str_detect() for partial matching:
Code
# Partial name search — more robust than exact matchingausten_works <-gutenberg_works( stringr::str_detect(author, "Austen"))cat("Works matching 'Austen':", nrow(austen_works), "\n")
# A tibble: 25 × 3
gutenberg_id title author
<int> <chr> <chr>
1 105 Persuasion Austen, Jane
2 121 Northanger Abbey Austen, Jane
3 141 Mansfield Park Austen, Jane
4 158 Emma Austen, Jane
5 161 Sense and Sensibility Austen, Jane
6 946 Lady Susan Austen, Jane
7 1212 Love and Freindship [sic] Austen, Jane
8 1342 Pride and Prejudice Austen, Jane
9 17797 Memoir of Jane Austen Austen-Leigh…
10 22536 Jane Austen, Her Life and Letters: A Family Record Austen-Leigh…
# ℹ 15 more rows
Filter by Language
The language field uses ISO 639-1 two-letter codes:
# Count texts per language across the full cataloguelang_counts <- gutenberg_metadata |> dplyr::filter(has_text ==TRUE) |> dplyr::count(language, sort =TRUE) |> dplyr::filter(!is.na(language)) |>head(15)
Code
lang_counts |> dplyr::mutate(language =reorder(language, n)) |>ggplot(aes(x = language, y = n)) +geom_col(fill ="steelblue", width =0.7) +coord_flip() +labs(title ="Project Gutenberg: Texts by Language",subtitle ="Top 15 languages (texts with downloadable plain text only)",x ="Language (ISO 639-1)",y ="Number of texts" ) +theme_bw() +theme(panel.grid.minor =element_blank())
Filter by Subject / Bookshelf
Project Gutenberg organises texts into thematic “bookshelves”:
Code
# Find all texts on the Science Fiction bookshelfscifi <-gutenberg_works( stringr::str_detect(gutenberg_bookshelf, "Science Fiction"))cat("Science Fiction texts:", nrow(scifi), "\n")
# A tibble: 10 × 3
gutenberg_id title author
<int> <chr> <chr>
1 35 The Time Machine Wells, H. G. (H…
2 36 The war of the worlds Wells, H. G. (H…
3 42 The strange case of Dr. Jekyll and Mr. Hyde Stevenson, Robe…
4 62 A princess of Mars Burroughs, Edga…
5 64 The gods of Mars Burroughs, Edga…
6 68 The warlord of Mars Burroughs, Edga…
7 72 Thuvia, maid of Mars Burroughs, Edga…
8 83 From the Earth to the moon; and, round the moon Verne, Jules
9 84 Frankenstein; or, the modern prometheus Shelley, Mary W…
10 86 A Connecticut Yankee in King Arthur's Court Twain, Mark
Code
# Browse the top 20 most populated bookshelvesgutenberg_metadata |> dplyr::filter(!is.na(gutenberg_bookshelf), has_text ==TRUE) |> tidyr::separate_rows(gutenberg_bookshelf, sep ="/") |> dplyr::mutate(gutenberg_bookshelf = stringr::str_trim(gutenberg_bookshelf)) |> dplyr::count(gutenberg_bookshelf, sort =TRUE) |>head(20)
# A tibble: 20 × 2
gutenberg_bookshelf n
<chr> <int>
1 Category: Novels 24592
2 Category: British Literature 10207
3 Category: Adventure 8646
4 Category: American Literature 7990
5 Category: Children & Young Adult Reading 7086
6 Category: History - Modern (1750+) 6580
7 Category: Biographies 6461
8 Category: Essays, Letters & Speeches 5617
9 Category: Historical Novels 5590
10 Category: History - American 5364
11 Category: Humour 5095
12 Category: Poetry 5057
13 Category: Short Stories 4948
14 Category: French Literature 4395
15 Category: History - European 4357
16 Category: Science-Fiction & Fantasy 4348
17 Category: Religion 4321
18 Spirituality 4321
19 Category: Travel Writing 4040
20 Category: History - Other 3683
Filter by Multiple Criteria
Combine conditions to narrow the catalogue precisely:
# A tibble: 10 × 4
gutenberg_id title author gutenberg_bookshelf
<int> <chr> <chr> <chr>
1 32 Herland Gilma… Best Books Ever Li…
2 35 The Time Machine Wells… Science Fiction/Mo…
3 36 The war of the worlds Wells… Science Fiction/Mo…
4 42 The strange case of Dr. Jekyll and M… Steve… Precursors of Scie…
5 54 The Marvelous Land of Oz Baum,… Children's Literat…
6 55 The Wonderful Wizard of Oz Baum,… Children's Literat…
7 62 A princess of Mars Burro… Science Fiction/Be…
8 64 The gods of Mars Burro… Science Fiction/Ca…
9 68 The warlord of Mars Burro… Science Fiction/Ca…
10 72 Thuvia, maid of Mars Burro… Science Fiction/Ca…
Downloading Individual Texts
Section Overview
What you’ll learn: How to download a single text by ID using gutenberg_safe(), and what the downloaded data looks like
Always Use the Gutenberg ID
Every text has a unique numeric ID visible in its Project Gutenberg URL (e.g., gutenberg.org/ebooks/1513). Downloading by ID is more reliable than searching by title, which can match multiple entries. Use gutenberg_works() or browse gutenberg.org to look up IDs before downloading.
Download Romeo and Juliet (ID: 1513)
Code
# Download Romeo and Juliet using gutenberg_safe()# gutenberg_safe() tries multiple mirrors, then falls back to the direct cache URLromeo <-gutenberg_safe(1513)cat("Downloaded:", nrow(romeo), "lines\n")
The meta_fields argument attaches metadata columns to the downloaded text — useful when combining multiple texts into a corpus:
Code
# Download On the Origin of Species with title, author, and language attachedorigin_species <-gutenberg_safe(1228, # On the Origin of Speciesmeta_fields =c("title", "author", "language"))cat("Title:", unique(origin_species$title), "\n")
Title: On the Origin of Species By Means of Natural Selection
Or, the Preservation of Favoured Races in the Struggle for Life
What you’ll learn: How to download several texts at once and organise them into a labelled corpus ready for analysis
Downloading by ID Vector
To download multiple texts, call gutenberg_safe() for each ID and combine the results with dplyr::bind_rows():
Code
# Download Wuthering Heights (768) and Jane Eyre (1260)# Call gutenberg_safe() for each ID, then stack the resultsbronte_texts <- dplyr::bind_rows(gutenberg_safe(768), # Wuthering Heights — Emily Brontëgutenberg_safe(1260) # Jane Eyre — Charlotte Brontë)# How many lines from each text?bronte_texts |> dplyr::count(title, name ="lines")
# A tibble: 2 × 2
title lines
<chr> <int>
1 Jane Eyre: An Autobiography 21001
2 Wuthering Heights 12342
title
Number of lines
Jane Eyre: An Autobiography
21,001
Wuthering Heights
12,342
Downloading All Works by an Author
Retrieve all IDs for an author from the catalogue, then loop through them:
Code
# Find all Charles Dickens IDsdickens_ids <-gutenberg_works( author =="Dickens, Charles", language =="en") |> dplyr::pull(gutenberg_id)cat("Dickens texts available:", length(dickens_ids), "\n")
# Download all Dickens texts — this may take several minutes# purrr::map_dfr() loops over each ID and stacks the resultsdickens_corpus <- purrr::map_dfr( dickens_ids,~gutenberg_safe(.x, meta_fields =c("title", "author")))cat("Total lines:", nrow(dickens_corpus), "\n")cat("Texts downloaded:", length(unique(dickens_corpus$title)), "\n")
Large Downloads
Downloading many texts at once can take several minutes. Best practices:
Save immediately after downloading (see the Saving section below) to avoid re-downloading
Download in batches if fetching more than ~20 texts
Be respectful of Project Gutenberg’s resources — it is a non-profit volunteer project
Building a Multi-Author Corpus
Code
# Download three 19th-century texts for comparative analysis:# Moby Dick (2701), Pride and Prejudice (1342), On the Origin of Species (1228)comparison_corpus <- dplyr::bind_rows(gutenberg_safe(2701, meta_fields =c("title", "author")), # Moby Dickgutenberg_safe(1342, meta_fields =c("title", "author")), # Pride and Prejudicegutenberg_safe(1228, meta_fields =c("title", "author")) # On the Origin of Species) |># If the author column is missing (fallback download), add it from metadata (\(df) {if (!"author"%in%names(df)) { df <- df |> dplyr::left_join( gutenberg_metadata |> dplyr::select(gutenberg_id, author),by ="gutenberg_id" ) } df })()# Corpus summarycomparison_corpus |> dplyr::group_by(author, title) |> dplyr::summarise(lines = dplyr::n(),words =sum(stringr::str_count(text, "\\S+"), na.rm =TRUE),.groups ="drop" )
# A tibble: 3 × 4
author title lines words
<chr> <chr> <int> <int>
1 Austen, Jane "Pride and Prejudice" 14529 127360
2 Darwin, Charles "On the Origin of Species By Means of Natural S… 16188 155500
3 Melville, Herman "Moby Dick; Or, The Whale" 21928 212796
author
title
Lines
Words
Austen, Jane
Pride and Prejudice
14,529
127,360
Darwin, Charles
On the Origin of Species By Means of Natural Selection
Or, the Preservation of Favoured Races in the Struggle for Life
16,188
155,500
Melville, Herman
Moby Dick; Or, The Whale
21,928
212,796
Cleaning and Preparing Downloaded Texts
Section Overview
What you’ll learn: How to remove Project Gutenberg boilerplate, collapse lines into continuous text, split into chapters or acts, and save cleaned texts for analysis
Why this matters: Raw downloads include licence notices and formatting artefacts that distort frequency analysis, topic models, and other quantitative methods if not removed.
What Raw Downloads Look Like
Each download is a line-by-line data frame. The first and last portions contain boilerplate licence text:
[1] "THE TRAGEDY OF ROMEO AND JULIET"
[2] ""
[3] "by William Shakespeare"
[4] ""
[5] ""
[6] ""
[7] ""
[8] "Contents"
[9] ""
[10] "THE PROLOGUE."
[11] ""
[12] "ACT I"
[13] "Scene I. A public place."
[14] "Scene II. A Street."
[15] "Scene III. Room in Capulet’s House."
[16] "Scene IV. A Street."
[17] "Scene V. A Hall in Capulet’s House."
[18] ""
[19] "ACT II"
[20] "CHORUS."
[21] "Scene I. An open place adjoining Capulet’s Garden."
[22] "Scene II. Capulet’s Garden."
[23] "Scene III. Friar Lawrence’s Cell."
[24] "Scene IV. A Street."
[25] "Scene V. Capulet’s Garden."
[26] "Scene VI. Friar Lawrence’s Cell."
[27] ""
[28] "ACT III"
[29] "Scene I. A public Place."
[30] "Scene II. A Room in Capulet’s House."
[1] "MONTAGUE."
[2] "But I can give thee more,"
[3] "For I will raise her statue in pure gold,"
[4] "That whiles Verona by that name is known,"
[5] "There shall no figure at such rate be set"
[6] "As that of true and faithful Juliet."
[7] ""
[8] "CAPULET."
[9] "As rich shall Romeo’s by his lady’s lie,"
[10] "Poor sacrifices of our enmity."
[11] ""
[12] "PRINCE."
[13] "A glooming peace this morning with it brings;"
[14] "The sun for sorrow will not show his head."
[15] "Go hence, to have more talk of these sad things."
[16] "Some shall be pardon’d, and some punished,"
[17] "For never was a story of more woe"
[18] "Than this of Juliet and her Romeo."
[19] ""
[20] " [_Exeunt._]"
Removing Boilerplate
Project Gutenberg texts often contain boilerplate before and after the literary work. Because the formatting of downloaded texts can vary, rather than relying on the exact Gutenberg boundary markers, we can inspect the text and identify the beginning and end of the literary work directly.
For this version of Romeo and Juliet, the literary text begins with THE PROLOGUE.. The literary text ends with the final non-empty line of the file.
Code
# Find the beginning of the literary textstart_marker <-which( stringr::str_detect(romeo$text, "^\\s*THE PROLOGUE\\.\\s*$"))[1]# Find the last non-empty lineend_marker <-max(which(!is.na(romeo$text) & stringr::str_trim(romeo$text) !=""))cat("Start of literary text:", start_marker, "\n")
Start of literary text: 10
Code
cat("End of literary text:", end_marker, "\n")
End of literary text: 5265
Code
# Check that the beginning was foundif (is.na(start_marker)) {stop("Could not identify the beginning of the literary text. ","Inspect the first lines using head()." )}# Keep only the literary textromeo_clean <- romeo |> dplyr::slice(start_marker:end_marker) |> dplyr::filter(!is.na(text))
If the markers are not found, as can happen with some downloaded or pre-processed versions of Project Gutenberg texts, inspect the first and last lines of the object before deciding where the literary text begins and ends.
Removing Empty Lines
Code
# Remove lines that are empty or contain only whitespaceromeo_clean <- romeo_clean |> dplyr::filter(stringr::str_trim(text) !="")cat("Lines after removing empty lines:", nrow(romeo_clean), "\n")
Lines after removing empty lines: 4134
Collapsing to a Single String
Code
# Join all lines into one continuous string, then normalise whitespaceromeo_text <- romeo_clean$text |>paste(collapse =" ") |> stringr::str_squish()cat("Total characters:", nchar(romeo_text), "\n")
First 300 characters:
THE PROLOGUE. ACT I Scene I. A public place. Scene II. A Street. Scene III. Room in Capulet’s House. Scene IV. A Street. Scene V. A Hall in Capulet’s House. ACT II CHORUS. Scene I. An open place adjoining Capulet’s Garden. Scene II. Capulet’s Garden. Scene III. Friar Lawrence’s Cell. Scene IV. A Str
Splitting into Acts and Scenes
Code
# Split Romeo and Juliet into Acts using a regex on Roman numeral headingsacts <- romeo_text |> stringr::str_replace_all("(ACT [IVX]+\\.?)", "|||\\1") |># insert split marker stringr::str_split("\\|\\|\\|") |>unlist() |> (\(x) x[nchar(stringr::str_trim(x)) >20])() # drop very short fragmentscat("Segments found:", length(acts), "\n")
Segment 2 begins: ACT II CHORUS. Scene I. An open place adjoining Capulet’s Garden. Scene II. Capulet’s Garden. Scene III. Friar Lawrence’
Splitting into Chapters
Code
# Clean Wuthering Heights from the bronte_texts corpuswuthering <- bronte_texts |> dplyr::filter(stringr::str_detect(title, "Wuthering"))# Diagnostic: check what the opening lines look like# (useful for seeing the exact marker format used)cat("First 5 lines:\n")
First 5 lines:
Code
cat(head(wuthering$text, 5), sep ="\n")
Wuthering Heights
by Emily Brontë
Code
# Find boilerplate markers — try several common variantswh_start <-which(stringr::str_detect( wuthering$text, stringr::regex("\\*{3}\\s*START OF", ignore_case =TRUE)))wh_end <-which(stringr::str_detect( wuthering$text, stringr::regex("\\*{3}\\s*END OF", ignore_case =TRUE)))# If markers not found, use the full text with no trimmingif (length(wh_start) ==0) {cat("START marker not found — using full text\n") wh_start <-0L}
START marker not found — using full text
Code
if (length(wh_end) ==0) {cat("END marker not found — using full text\n") wh_end <-nrow(wuthering) +1L}
END marker not found — using full text
Code
# Slice between markers (or use full text if markers absent)wh_text <- wuthering |> dplyr::slice((wh_start[1] +1):(wh_end[1] -1)) |> dplyr::filter(stringr::str_trim(text) !="") |> dplyr::pull(text) |>paste(collapse =" ") |> stringr::str_squish()cat("Characters in cleaned text:", nchar(wh_text), "\n")
Chapter 1 begins: CHAPTER II Yesterday afternoon set in misty and cold. I had half a mind to spend it by my study fire, instead of wading through heath and mud to Wuthe
Saving Cleaned Texts
Save downloaded and cleaned data immediately to avoid re-downloading in future sessions:
Code
# Create data directory if neededif (!dir.exists(here::here("data"))) {dir.create(here::here("data"), recursive =TRUE)}# Save as RDS (R's native binary format — fast and lossless)saveRDS(romeo_text, here::here("data", "romeo_clean.rds"))saveRDS(wh_chapters, here::here("data", "wh_chapters.rds"))saveRDS(comparison_corpus, here::here("data", "comparison_corpus.rds"))# Save as plain text for use outside RwriteLines(romeo_text, here::here("data", "romeo_clean.txt"))cat("Saved to:", here::here("data"), "\n")
Code
# Load saved data in future sessions — no re-downloading neededromeo_text <-readRDS(here::here("data", "romeo_clean.rds"))wh_chapters <-readRDS(here::here("data", "wh_chapters.rds"))comparison_corpus <-readRDS(here::here("data", "comparison_corpus.rds"))
Troubleshooting
Section Overview
What you’ll learn: How to handle encoding issues and texts that are not found in the catalogue
Encoding Issues
Some older texts use Latin-1 encoding rather than UTF-8, producing garbled characters for accented letters:
Code
# Fix garbled characters by re-encoding from Latin-1 to UTF-8text_fixed <- text |> dplyr::mutate(text =iconv(text, from ="latin1", to ="UTF-8", sub ="byte") )# For individual stringsclean_line <-enc2utf8(text$text[1])
Text Not Found
If gutenberg_works() returns zero rows or gutenberg_safe() fails:
Code
# Problem 1: exact title match fails# Solution: partial, case-insensitive searchgutenberg_works( stringr::str_detect(stringr::str_to_lower(title), "romeo"))# Problem 2: text has no downloadable plain text version# Solution: check has_text == TRUEgutenberg_metadata |> dplyr::filter(title =="Romeo and Juliet", has_text ==TRUE)# Problem 3: check rights statusgutenberg_metadata |> dplyr::filter(title =="Romeo and Juliet") |> dplyr::select(gutenberg_id, title, rights, has_text)
Verifying a Download
Code
# Reusable function to quickly check a downloaded text data frameverify_download <-function(text_df, min_lines =100) {cat("--- Download Verification ---\n")cat("Rows:", nrow(text_df), "\n")cat("Columns:", paste(names(text_df), collapse =", "), "\n")cat("Empty lines:", sum(is.na(text_df$text) | text_df$text ==""), "\n")if ("title"%in%names(text_df)) cat("Title:", unique(text_df$title), "\n")if ("author"%in%names(text_df)) cat("Author:", unique(text_df$author), "\n")if (nrow(text_df) < min_lines) warning("Download seems very short — check for errors")cat("First non-empty line:", text_df$text[which(nzchar(text_df$text))[1]], "\n")}verify_download(romeo, min_lines =500)
--- Download Verification ---
Rows: 5265
Columns: gutenberg_id, text, title
Empty lines: 1128
Title: Romeo and Juliet
First non-empty line: THE TRAGEDY OF ROMEO AND JULIET
AI Statement
This how-to guide was substantially revised and expanded from the original LADAL draft (gutenberg.qmd) with the assistance of Claude (Anthropic), an AI language model. The AI was used to: restructure the guide into a logical sequence of sections; add the gutenberg_safe() helper function (applying the mirror-loop + direct-URL fallback pattern consistently across all download calls, replacing the original gutenberg_download() pipe approach that returned empty results); expand filtering coverage to include bookshelf filtering, multi-criteria filtering, and partial name matching; add the cleaning and preparation section (boilerplate removal, splitting into acts/chapters, saving/loading); add the troubleshooting section; add the language frequency bar plot and metadata fields table; convert all formatting to Quarto callouts and LADAL flextable style; and update the YAML and citation. All content and workflow decisions were reviewed by the tutorial author.
Citation & Session Info
Citation
Martin Schweinberger. 2026. Downloading Texts from Project Gutenberg using R. The Language Technology and Data Analysis Laboratory (LADAL), The University of Queensland, Australia. url: https://ladal.edu.au/tutorials/gutenberg/gutenberg.html (Version 3.1.1). doi: 10.5281/zenodo.19424866.
@manual{martinschweinberger2026downloading,
author = {Martin Schweinberger},
title = {Downloading Texts from Project Gutenberg using R},
year = {2026},
note = {https://ladal.edu.au/tutorials/gutenberg/gutenberg.html},
organization = {The Language Technology and Data Analysis Laboratory (LADAL), The University of Queensland, Australia},
edition = {3.1.1}
doi = {10.5281/zenodo.19424866}
}
This tutorial was re-developed with the assistance of Claude (claude.ai), a large language model created by Anthropic. Claude was used to help revise the tutorial text, structure the instructional content, generate the R code examples, and write the checkdown quiz questions and feedback strings. All content was reviewed, edited, and approved by the author (Martin Schweinberger), who takes full responsibility for the accuracy and pedagogical appropriateness of the material. The use of AI assistance is disclosed here in the interest of transparency and in accordance with emerging best practices for AI-assisted academic content creation.